Skip to content

docs: parallel canister calls guide#97

Merged
marc0olo merged 2 commits into
mainfrom
docs/guides-canister-calls-parallel
Apr 16, 2026
Merged

docs: parallel canister calls guide#97
marc0olo merged 2 commits into
mainfrom
docs/guides-canister-calls-parallel

Conversation

@marc0olo
Copy link
Copy Markdown
Member

Summary

  • Sequential vs parallel call comparison; when to use parallel (cross-subnet latency)
  • Motoko: mo:core imports, parallel call pattern, partial failure handling
  • Rust: ic-cdk 0.19 Call::bounded_wait, futures::future::join_all pattern
  • In-flight call limit and retry-via-timer pattern
  • Composite queries: annotations, restrictions, Motoko and Rust examples
  • Decision table: sequential vs parallel vs composite
  • Security considerations: atomicity, reentrancy, callee trust

Note: stub renamed from .md to .mdx for language-tab components.

Sync recommendation

informed by dfinity/examples — motoko/parallel_calls, rust/parallel_calls, motoko/composite_query, rust/composite_query

@marc0olo
Copy link
Copy Markdown
Member Author

Review: Parallel Calls

Must fix

  • Incorrect comment: into_future() does not send the request immediately: The inline code comment in the Rust parallel example states "each into_future() sends the request message immediately." This is incorrect. According to .sources/cdk-rs/ic-cdk/src/call.rs, into_future() returns a CallFuture in Prepared state — the request is only sent (via call.perform(...)) when the future is first polled, which happens during join_all(calls).await. The correct behavior is that all requests are dispatched before any response is awaited (which is what join_all accomplishes at await time), not that into_future() itself dispatches them. Suggested fix: change the comment from // Build all futures before awaiting any of them — each into_future() sends the request message immediately. to // Build all futures before awaiting any of them. All requests are dispatched when join_all polls each future — all fire before any response is awaited.

  • Imprecise API description in "How parallel calls work" section: The text says "Call::bounded_wait returns an IntoFuture." This is misleading — Call::bounded_wait returns a Call<'m, 'a>, which implements IntoFuture. Suggested fix: "You can collect calls into a Vec by calling .into_future() on each Call::bounded_wait(...) expression (since Call implements IntoFuture), then pass them to futures::future::join_all, which awaits all of them together."

Suggestions

  • Composite query Rust example uses Arc<RwLock<>> in thread_local\! where RefCell would suffice: WASM canisters are single-threaded; std::sync::RwLock compiles for wasm32 but adds unnecessary complexity and misleads readers into thinking thread safety is required. The thread_local\! + RefCell pattern is the standard convention in all ic-cdk examples. Suggested fix: replace Arc<RwLock<Vec<Principal>>> with RefCell<Vec<Principal>>, update the read to .borrow(), and remove the std::sync::{Arc, RwLock} imports.

  • BoxFuture<_> + Box::pin adds complexity without explanation: The parallel Rust example boxes each future with Box::pin(...) as BoxFuture<_>. This is valid (erases lifetime parameters so the futures can be collected into a homogeneous Vec<BoxFuture<_>>), but the reason isn't explained. Adding a comment like // Box::pin erases the lifetime so futures can be collected into a Vec would help readers understand why this is needed. This is a minor style enhancement.

  • In-flight limit section could name the approximate limit (~500 per canister pair): The source README (dfinity/examples/motoko/parallel_calls/README.md) shows that with 2000 parallel calls only ~500 succeed, indicating a per-canister-pair in-flight limit near 500. Naming a concrete number helps developers size their batches. This is an enhancement, not a bug.

Verified

  • All internal links resolve: ../backends/timers.mdtimers.mdx exists; onchain-calls.mdonchain-calls.mdx exists; ../canister-management/optimization.md exists; ../security/inter-canister-calls.md exists. Astro resolves .md links to .mdx files — all four links are valid.
  • External links: GitHub example links point to the correct paths in dfinity/examples (motoko/parallel_calls, rust/parallel_calls, motoko/composite_query, rust/composite_query). All four paths verified to exist in .sources/examples/. cli.internetcomputer.org link is on the allowed host list.
  • Frontmatter: Complete — title, description, and sidebar.order present with no contradictions between front matter and body.
  • Motoko mo:core imports verified: List, Nat, Error, Principal, Array — all exist in .sources/motoko-core/src/. No mo:base imports present.
  • List.empty, List.add, List.values API verified against .sources/motoko-core/src/List.mo: signatures and argument order are correct. The PR correctly adapts from the mo:base linked-list API (nil/push/toIter) to the mo:core dynamic-array API (empty/add/values).
  • Nat.range(0, n) verified against .sources/motoko-core/src/Nat.mo: range(fromInclusive, toExclusive)Nat.range(0, n) produces exactly n iterations. Correct.
  • Call::bounded_wait and Call::unbounded_wait verified against .sources/cdk-rs/ic-cdk/src/call.rs: both constructors exist with the correct signatures. Return type of .await is Result<Response, CallFailed>, which has .is_ok(). Correct.
  • future::join_all usage verified: results.iter().filter(|r| r.is_ok()).count() as u64 is valid — count() returns usize, cast to u64 matches the function signature.
  • Composite query restrictions verified against .sources/portal/docs/building-apps/interact-with-canisters/query-calls.mdx: "Cannot call canisters on another subnet" and "Cannot be called as an update" are both confirmed in the portal comparison table.
  • Composite query annotations table verified: Motoko composite query, Rust #[query(composite = true)], Candid composite_query — confirmed against portal source and cdk-rs example source.
  • In-flight limit section verified against .sources/examples/motoko/parallel_calls/README.md: the per-callee in-flight limit causing failures above ~500 concurrent calls, and the retry-via-timer guidance, are both sourced directly from the README. Accurate.
  • Atomicity / reentrancy security section: Content is consistent with the multi-canister skill's security guidance on reentrancy, partial failure, and bounded wait for untrusted callees.
  • <\!-- Upstream: --> comment: Present as {/* Upstream: ... */} — correct format for .mdx files, consistent with other .mdx files in the repo.
  • No dfx references: Confirmed.
  • persistent actor used in all Motoko examples: Correct.
  • Content structure (Diataxis how-to): Prerequisites → concept explanation → sequential example → parallel example → in-flight limit → partial failure → composite queries → decision table → security → next steps. Clear orient → instruct → next-steps funnel. No content buried.
  • Code examples under 30 lines inline: All snippets within limit. Full examples linked to dfinity/examples instead of inlined. Correct.

- Fix incorrect into_future() comment: requests are dispatched on first
  poll() inside join_all, not when into_future() is called (CallFuture
  starts in Prepared state per cdk-rs call.rs)
- Fix imprecise API description: Call::bounded_wait returns Call<'m,'a>
  which implements IntoFuture, not "an IntoFuture"
- Add Box::pin comment explaining lifetime erasure for Vec collection
- Name approximate in-flight limit (~500 per canister pair) per examples README
- Replace Arc<RwLock<>> with RefCell in composite query example (WASM is
  single-threaded; RefCell is the standard ic-cdk convention)
@marc0olo
Copy link
Copy Markdown
Member Author

<!-- feedback-addressed -->

Feedback addressed — PR #97 (parallel-calls)

Reviewer: marc0olo

Changes applied

Must fix — both applied:

  1. Corrected into_future() comment (was factually wrong): The original comment said "each into_future() sends the request message immediately." This is incorrect. Verified against .sources/cdk-rs/ic-cdk/src/call.rs: into_future() returns a CallFuture in Prepared state; perform() (which actually enqueues the request via ic0.call_perform) is only called during poll(), which happens inside join_all(calls).await. Updated to: "All requests are dispatched when join_all polls each future — all fire before any response is awaited." Also added: "Box::pin erases the lifetime parameters so futures can be collected into a homogeneous Vec."

  2. Fixed imprecise API description in "How parallel calls work": The original text said "Call::bounded_wait returns an IntoFuture." Verified: Call::bounded_wait returns Self (i.e., Call<'m, 'a>), which implements IntoFuture. Updated to: "you can collect calls into a Vec by calling .into_future() on each Call::bounded_wait(...) expression (since Call implements IntoFuture)."

Suggestions — all applied:

  1. Replaced Arc<RwLock<Vec<Principal>>> with RefCell<Vec<Principal>> in composite query Rust example: WASM canisters are single-threaded; Arc<RwLock<>> compiles but adds unnecessary complexity and misleads readers. The thread_local\! + RefCell pattern is the standard ic-cdk convention (verified in .sources/cdk-rs/e2e-tests/src/bin/simple_kv_store.rs). Updated import and changed .read().unwrap() to .borrow().

  2. Named the approximate in-flight limit (~500 per canister pair): Sourced from .sources/examples/motoko/parallel_calls/README.md: running 2000 parallel calls yields ~500 successes, confirming the per-canister-pair limit near 500. Updated the in-flight limit section to include "approximately 500 per canister pair."

Items skipped

None — all feedback items were factually correct and well-sourced. All must-fix and suggestion items were applied.

@marc0olo marc0olo merged commit 125167e into main Apr 16, 2026
1 check passed
@marc0olo marc0olo deleted the docs/guides-canister-calls-parallel branch April 16, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant